Skip to content

cache: sentinel to invalidate the chunk index during fragment deletion#9908

Open
mr-raj12 wants to merge 4 commits into
borgbackup:masterfrom
mr-raj12:fix-9904-chunkindex-invalidated-sentinel
Open

cache: sentinel to invalidate the chunk index during fragment deletion#9908
mr-raj12 wants to merge 4 commits into
borgbackup:masterfrom
mr-raj12:fix-9904-chunkindex-invalidated-sentinel

Conversation

@mr-raj12

@mr-raj12 mr-raj12 commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

What

Deleting chunk index fragments is not crash safe: if the delete is interrupted partway, the leftover fragments look like a complete index and borg trusts them, so the index ends up missing entries or keeping stale ones.

This writes an "index invalid" marker before the first fragment delete and removes it after the last. While the marker is present, the index is rebuilt from packs on next load.

The marker lives out of band as a cache/chunkindex-invalid object, not in the index/ namespace, so it can never collide with a real index/<sha256> fragment. Its presence is detected with a cache/ listing rather than a load, and listing bypasses the store cache, so this stays correct even if cache/ gets a cache configuration later.

The marker only guards non-superset rewrites (full replacements and the invalidate-all path). Repack does not need it, since the fragments it writes already contain the entries it deletes.

Closes #9904

…ex fragments (borgbackup#9904)

An interrupted fragment deletion could leave a subset that build_chunkindex_from_repo
trusts as a complete index; the sentinel now forces a rebuild from packs instead.
@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.87879% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.28%. Comparing base (7da670c) to head (ea1d86d).
⚠️ Report is 5 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/borg/cache.py 86.20% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #9908      +/-   ##
==========================================
+ Coverage   85.13%   85.28%   +0.15%     
==========================================
  Files          93       93              
  Lines       15899    15932      +33     
  Branches     2428     2435       +7     
==========================================
+ Hits        13535    13588      +53     
+ Misses       1654     1635      -19     
+ Partials      710      709       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@ThomasWaldmann ThomasWaldmann left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in a future PR, we will also need to refactor these functions into a class dealing with index management.

Comment thread src/borg/cache.py Outdated
Comment thread src/borg/constants.py Outdated
Comment thread src/borg/constants.py Outdated
…pace

Store the marker as config/chunkindex-invalid instead of an all-zeros index/ fragment name, so it can never collide with a real fragment; detect it via a config listing.
@mr-raj12
mr-raj12 marked this pull request as ready for review July 14, 2026 20:31
Comment thread src/borg/repository.py Outdated

@ThomasWaldmann ThomasWaldmann left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the current state of the branch (through d344e6e). Overall this is in good shape: the out-of-band config/chunkindex-invalid marker is a clean design, the marker-before-delete ordering is correct, and I chased the concurrent interleavings (two shared-lock clients rolling forward; a roll-forward racing another client's close-write) without finding a way to end up with a wrong index — every window leaves either the marker in place or zero fragments, both safe states. The slow rebuild inserting everything with F_NEW also means a roll-forward client persists the full rebuilt index at close(), so the repo is never left with only a tiny incremental fragment being trusted as complete. Applied the diff locally and ran cache_test.py, repository_test.py, compact_cmd_test.py, check_cmd_test.py: 156 passed.

Two things I'd like settled before merge, one follow-up, and two nits:

1. Plain borg check and borg compact --dry-run now write to the repo. The roll-forward in build_chunkindex_from_repo() runs on any index build, so with a leftover marker, borg check (via ArchiveChecker) and borg compact --dry-run delete fragments plus marker under a shared lock — commands that promise not to modify anything. (The repo-check warning even says "will be rebuilt on next use", and then check's own archive phase is that next use and mutates the repo.) Suggest a rollforward=True kwarg on build_chunkindex_from_repo(), passed as False by the checker and by dry-run compact — those callers would still break to the slow rebuild, just without deleting. Alternatively, if finishing an interrupted deletion is considered acceptable maintenance even from these commands, a comment saying so explicitly would do.

2. Version-skew trade-off of the out-of-band marker. An earlier revision's in-band index/000…0 sentinel made any older borg2 client fail the fragment merge (unreadable "fragment") and fall back to the safe slow rebuild. With the marker in config/, an older client on a shared repo sees only the leftover fragments and trusts them as a complete index — the exact #9904 failure, now reachable through version skew. Probably acceptable at this stage, but it should be a deliberate choice, and a changelog note recommending clients upgrade together would cover it.

3. (follow-up material) Guard window in write_chunkindex_to_repo. The marker is written after the new fragments are stored, so a crash between storing and the marker write leaves old + new fragment sets coexisting unguarded; the merge is last-write-wins in fragment-name order, so deleted entries can resurrect nondeterministically. Today this is only reachable via Repository.delete(update_index=True) (i.e. borg debug delete-obj), since compact wipes the index up-front — but moving the guard write before the store loop would close it for free, or at least a comment acknowledging the window.

Nits:

  • No test interrupts the guarded delete_other deletion inside write_chunkindex_to_repo itself; interruption coverage is only on delete_chunkindex_from_repo.
  • The happy path now costs one extra store_list("config") round-trip per index build (and one per repack, i.e. per backup close). Negligible locally, measurable on high-latency remotes; fine to leave as is.

Compatibility details I checked and that are fine: config/ already holds variable-name objects (space-reserve.*) and every consumer filters by name/prefix, so the marker can't confuse anything; repository.check() doesn't content-verify config/ objects, so the empty marker can't be flagged as corruption; the function-level from .cache import chunkindex_is_invalid in repository.py matches the existing pattern there (top-level would be circular).

🤖 Review drafted with Claude Code

@mr-raj12

Copy link
Copy Markdown
Contributor Author

3 and borg debug delete-obj, leaving as discussed, also moved the marker to the cache/ namespace in ea1d86d. compact filters its cache/ listing by the referenced-by-archive prefix and the only other object there is last-pack-checked, so there is no name clash.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

borg2: partial index delete problematic

2 participants